Implement server auth bootstrap and pairing flow#1768
Implement server auth bootstrap and pairing flow#1768juliusmarminge wants to merge 49 commits intomainfrom
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Pairing URL points to root, token lost during redirect
- Added
url.pathname = "/pair"inissueStartupPairingUrlso the generated URL navigates directly to/pair?token=..., avoiding the redirect that strips the query parameter.
- Added
- ✅ Fixed: Secret store
setswallows write errors silently- Changed
Effect.map(() => new SecretStoreError(...))toEffect.flatMap(() => Effect.fail(new SecretStoreError(...)))so write failures properly propagate as Effect errors instead of being swallowed as success values.
- Changed
- ✅ Fixed: Bootstrap credential consume has TOCTOU race condition
- Replaced the non-atomic
Ref.get+Ref.updatesequence with a singleRef.modifycall that atomically reads the grant, validates it, and updates the map in one operation.
- Replaced the non-atomic
- ✅ Fixed: Duplicate one-time tokens issued during startup
- In non-desktop mode,
resolveStartupBrowserTargetis now called once and the resulting URL is reused for both logging and browser opening, avoiding issuing two separate one-time tokens.
- In non-desktop mode,
Or push these changes by commenting:
@cursor push f99e419f7c
Preview (f99e419f7c)
diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts
--- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts
+++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts
@@ -49,47 +49,60 @@
return credential;
});
+ type ConsumeResult =
+ | { readonly _tag: "error"; readonly error: BootstrapCredentialError }
+ | { readonly _tag: "ok"; readonly grant: BootstrapGrant };
+
const consume: BootstrapCredentialServiceShape["consume"] = (credential) =>
Effect.gen(function* () {
- const current = yield* Ref.get(grantsRef);
- const grant = current.get(credential);
- if (!grant) {
- return yield* new BootstrapCredentialError({
- message: "Unknown bootstrap credential.",
- });
- }
+ const now = yield* DateTime.now;
+ const result = yield* Ref.modify(
+ grantsRef,
+ (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
+ const grant = current.get(credential);
+ if (!grant) {
+ return [
+ {
+ _tag: "error",
+ error: new BootstrapCredentialError({ message: "Unknown bootstrap credential." }),
+ },
+ current,
+ ];
+ }
- if (DateTime.isGreaterThanOrEqualTo(yield* DateTime.now, grant.expiresAt)) {
- yield* Ref.update(grantsRef, (state) => {
- const next = new Map(state);
- next.delete(credential);
- return next;
- });
- return yield* new BootstrapCredentialError({
- message: "Bootstrap credential expired.",
- });
- }
-
- const remainingUses = grant.remainingUses;
- if (typeof remainingUses === "number") {
- yield* Ref.update(grantsRef, (state) => {
- const next = new Map(state);
- if (remainingUses <= 1) {
+ if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
+ const next = new Map(current);
next.delete(credential);
- } else {
- next.set(credential, {
- ...grant,
- remainingUses: remainingUses - 1,
- });
+ return [
+ {
+ _tag: "error",
+ error: new BootstrapCredentialError({ message: "Bootstrap credential expired." }),
+ },
+ next,
+ ];
}
- return next;
- });
+
+ const next = new Map(current);
+ const remainingUses = grant.remainingUses;
+ if (typeof remainingUses === "number") {
+ if (remainingUses <= 1) {
+ next.delete(credential);
+ } else {
+ next.set(credential, { ...grant, remainingUses: remainingUses - 1 });
+ }
+ }
+
+ return [
+ { _tag: "ok", grant: { method: grant.method, expiresAt: grant.expiresAt } },
+ next,
+ ];
+ },
+ );
+
+ if (result._tag === "error") {
+ return yield* result.error;
}
-
- return {
- method: grant.method,
- expiresAt: grant.expiresAt,
- } satisfies BootstrapGrant;
+ return result.grant;
});
return {
diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts
--- a/apps/server/src/auth/Layers/ServerAuth.ts
+++ b/apps/server/src/auth/Layers/ServerAuth.ts
@@ -124,6 +124,7 @@
bootstrapCredentials.issueOneTimeToken().pipe(
Effect.map((credential) => {
const url = new URL(baseUrl);
+ url.pathname = "/pair";
url.searchParams.set("token", credential);
return url.toString();
}),
diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts
--- a/apps/server/src/auth/Layers/ServerSecretStore.ts
+++ b/apps/server/src/auth/Layers/ServerSecretStore.ts
@@ -47,8 +47,10 @@
Effect.catch((cause) =>
fileSystem.remove(tempPath).pipe(
Effect.orElseSucceed(() => undefined),
- Effect.map(
- () => new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }),
+ Effect.flatMap(() =>
+ Effect.fail(
+ new SecretStoreError({ message: `Failed to persist secret ${name}.`, cause }),
+ ),
),
),
),
diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
--- a/apps/server/src/serverRuntimeStartup.ts
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -388,8 +388,22 @@
yield* Effect.logInfo("Authentication required. Open T3 Code using the pairing URL.", {
pairingUrl,
});
+ if (!serverConfig.noBrowser) {
+ const { openBrowser } = yield* Open;
+ yield* runStartupPhase(
+ "browser.open",
+ openBrowser(pairingUrl).pipe(
+ Effect.catch(() =>
+ Effect.logInfo("browser auto-open unavailable", {
+ hint: `Open ${pairingUrl} in your browser.`,
+ }),
+ ),
+ ),
+ );
+ }
+ } else {
+ yield* runStartupPhase("browser.open", maybeOpenBrowser);
}
- yield* runStartupPhase("browser.open", maybeOpenBrowser);
yield* Effect.logDebug("startup phase: complete");
}),
);You can send follow-ups to the cloud agent here.
ApprovabilityVerdict: Needs human review Diff is too large for automated approval analysis. A human reviewer should evaluate this PR. You can customize Macroscope's approvability policy. Learn more. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Auth bootstrap fetch uses unsupported ws:// protocol URL
- Added a wsUrlToHttpUrl() helper that converts ws:/wss: protocols to http:/https: and applied it at all three call sites where resolvePrimaryEnvironmentBootstrapUrl() is passed to fetch().
Or push these changes by commenting:
@cursor push 59ffed3254
Preview (59ffed3254)
diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts
--- a/apps/web/src/authBootstrap.test.ts
+++ b/apps/web/src/authBootstrap.test.ts
@@ -86,10 +86,10 @@
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toEqual(
- new URL("/api/auth/session", "ws://localhost:3773/"),
+ new URL("/api/auth/session", "http://localhost:3773/"),
);
expect(fetchMock.mock.calls[1]?.[0]).toEqual(
- new URL("/api/auth/bootstrap", "ws://localhost:3773/"),
+ new URL("/api/auth/bootstrap", "http://localhost:3773/"),
);
});
diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts
--- a/apps/web/src/authBootstrap.ts
+++ b/apps/web/src/authBootstrap.ts
@@ -2,6 +2,13 @@
import { resolvePrimaryEnvironmentBootstrapUrl } from "./environmentBootstrap";
+function wsUrlToHttpUrl(url: string): string {
+ const parsed = new URL(url);
+ if (parsed.protocol === "ws:") parsed.protocol = "http:";
+ else if (parsed.protocol === "wss:") parsed.protocol = "https:";
+ return parsed.href;
+}
+
export type ServerAuthGateState =
| { status: "authenticated" }
| {
@@ -80,7 +87,7 @@
}
async function bootstrapServerAuth(): Promise<ServerAuthGateState> {
- const baseUrl = resolvePrimaryEnvironmentBootstrapUrl();
+ const baseUrl = wsUrlToHttpUrl(resolvePrimaryEnvironmentBootstrapUrl());
const bootstrapCredential = getBootstrapCredential();
const currentSession = await fetchSessionState(baseUrl);
if (currentSession.authenticated) {
@@ -112,7 +119,10 @@
throw new Error("Enter a pairing token to continue.");
}
- await exchangeBootstrapCredential(resolvePrimaryEnvironmentBootstrapUrl(), trimmedCredential);
+ await exchangeBootstrapCredential(
+ wsUrlToHttpUrl(resolvePrimaryEnvironmentBootstrapUrl()),
+ trimmedCredential,
+ );
stripPairingTokenFromUrl();
}You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Cached auth state not invalidated after successful pairing
- Added
bootstrapPromise = nullafter the successful credential exchange insubmitServerAuthCredential, so subsequent calls toresolveInitialServerAuthGateStatere-evaluate the auth state instead of returning the stale cached requires-auth promise.
- Added
Or push these changes by commenting:
@cursor push 264a9be49a
Preview (264a9be49a)
diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts
--- a/apps/web/src/authBootstrap.ts
+++ b/apps/web/src/authBootstrap.ts
@@ -123,6 +123,7 @@
await exchangeBootstrapCredential(resolvePrimaryEnvironmentHttpBaseUrl(), trimmedCredential);
stripPairingTokenFromUrl();
+ bootstrapPromise = null;
}
export function resolveInitialServerAuthGateState(): Promise<ServerAuthGateState> {You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
There are 5 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared fixes for 2 of the 3 issues found in the latest run.
- ✅ Fixed: Token split allows extra segments to pass verification
- Added a check that
token.split(".")produces exactly 2 parts before destructuring, rejecting tokens with extra segments.
- Added a check that
- ✅ Fixed: Secret store getOrCreateRandom has TOCTOU race condition
- Wrapped the read-then-write sequence in
getOrCreateRandomwith aSemaphore(1)mutex to ensure atomicity.
- Wrapped the read-then-write sequence in
Or push these changes by commenting:
@cursor push f69311f5db
Preview (f69311f5db)
diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts
--- a/apps/server/src/auth/Layers/ServerSecretStore.ts
+++ b/apps/server/src/auth/Layers/ServerSecretStore.ts
@@ -1,6 +1,6 @@
import * as Crypto from "node:crypto";
-import { Effect, FileSystem, Layer, Path } from "effect";
+import { Effect, FileSystem, Layer, Path, Semaphore } from "effect";
import * as PlatformError from "effect/PlatformError";
import { ServerConfig } from "../../config.ts";
@@ -60,6 +60,8 @@
);
};
+ const mutex = yield* Semaphore.make(1);
+
const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
get(name).pipe(
Effect.flatMap((existing) => {
@@ -70,6 +72,7 @@
const generated = Crypto.randomBytes(bytes);
return set(name, generated).pipe(Effect.as(Uint8Array.from(generated)));
}),
+ mutex.withPermits(1),
);
const remove: ServerSecretStoreShape["remove"] = (name) =>
diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts
--- a/apps/server/src/auth/Layers/SessionCredentialService.ts
+++ b/apps/server/src/auth/Layers/SessionCredentialService.ts
@@ -56,7 +56,13 @@
});
const verify: SessionCredentialServiceShape["verify"] = Effect.fn("verify")(function* (token) {
- const [encodedPayload, signature] = token.split(".");
+ const parts = token.split(".");
+ if (parts.length !== 2) {
+ return yield* new SessionCredentialError({
+ message: "Malformed session token.",
+ });
+ }
+ const [encodedPayload, signature] = parts;
if (!encodedPayload || !signature) {
return yield* new SessionCredentialError({
message: "Malformed session token.",You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
🟢 Low
VITE_DEV_SERVER_URL is trimmed and validated by the parent on lines 8-17, but the child process receives the untrimmed value directly from process.env via childEnv. When the original environment variable contains whitespace, the parent accepts the URL after trimming while the child receives the raw value, causing URL parsing failures in the Electron app. Consider passing the validated devServerUrl to the child explicitly, or ensuring childEnv uses the trimmed value.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/scripts/dev-electron.mjs around line 65:
`VITE_DEV_SERVER_URL` is trimmed and validated by the parent on lines 8-17, but the child process receives the untrimmed value directly from `process.env` via `childEnv`. When the original environment variable contains whitespace, the parent accepts the URL after trimming while the child receives the raw value, causing URL parsing failures in the Electron app. Consider passing the validated `devServerUrl` to the child explicitly, or ensuring `childEnv` uses the trimmed value.
Evidence trail:
apps/desktop/scripts/dev-electron.mjs lines 8, 37, and 65-73 at REVIEWED_COMMIT:
- Line 8: `const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();` (trimmed for validation)
- Line 37: `const childEnv = { ...process.env };` (spreads original untrimmed env)
- Lines 65-73: `spawn(..., { env: childEnv, ... })` (child receives untrimmed value)
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 7 total unresolved issues (including 5 from previous reviews).
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Session token exposed in response body alongside HttpOnly cookie
- Stripped sessionToken from the bootstrap JSON response body by destructuring it out before serialization, so the token is only transmitted via the httpOnly cookie.
- ✅ Fixed: Session cookie missing
secureflag for non-loopback environments- Added conditional
secure: descriptor.policy === "remote-reachable"to the cookie options so the flag is set when the server is configured for remote access.
- Added conditional
Or push these changes by commenting:
@cursor push 21a3606f32
Preview (21a3606f32)
diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -40,12 +40,14 @@
);
const result = yield* serverAuth.exchangeBootstrapCredential(payload.credential);
- return yield* HttpServerResponse.jsonUnsafe(result, { status: 200 }).pipe(
+ const { sessionToken: _token, ...responseBody } = result;
+ return yield* HttpServerResponse.jsonUnsafe(responseBody, { status: 200 }).pipe(
HttpServerResponse.setCookie(descriptor.sessionCookieName, result.sessionToken, {
expires: DateTime.toDate(result.expiresAt),
httpOnly: true,
path: "/",
sameSite: "lax",
+ secure: descriptor.policy === "remote-reachable",
}),
);
}).pipe(Effect.catchTag("AuthError", (error) => Effect.succeed(toUnauthorizedResponse(error)))),
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -492,6 +492,12 @@
return `http://127.0.0.1:${address.port}${pathname}`;
});
+function parseSessionTokenFromSetCookie(setCookie: string | null): string | null {
+ if (!setCookie) return null;
+ const match = /t3_session=([^;]+)/.exec(setCookie);
+ return match?.[1] ?? null;
+}
+
const bootstrapBrowserSession = (credential = defaultDesktopBootstrapToken) =>
Effect.gen(function* () {
const bootstrapUrl = yield* getHttpServerUrl("/api/auth/bootstrap");
@@ -509,13 +515,15 @@
const body = (yield* Effect.promise(() => response.json())) as {
readonly authenticated: boolean;
readonly sessionMethod: string;
- readonly sessionToken: string;
readonly expiresAt: string;
};
+ const cookie = response.headers.get("set-cookie");
+ const sessionToken = parseSessionTokenFromSetCookie(cookie);
return {
response,
body,
- cookie: response.headers.get("set-cookie"),
+ cookie,
+ sessionToken,
};
});
@@ -525,18 +533,18 @@
return cachedDefaultSessionToken;
}
- const { response, body } = yield* bootstrapBrowserSession(credential);
- if (!response.ok) {
+ const { response, sessionToken } = yield* bootstrapBrowserSession(credential);
+ if (!response.ok || !sessionToken) {
return yield* Effect.fail(
new Error(`Expected bootstrap session response to succeed, got ${response.status}`),
);
}
if (credential === defaultDesktopBootstrapToken) {
- cachedDefaultSessionToken = body.sessionToken;
+ cachedDefaultSessionToken = sessionToken;
}
- return body.sessionToken;
+ return sessionToken;
});
const getWsServerUrl = (
@@ -720,13 +728,15 @@
Effect.gen(function* () {
yield* buildAppUnderTest();
- const { response: bootstrapResponse, body: bootstrapBody } = yield* bootstrapBrowserSession();
+ const { response: bootstrapResponse, sessionToken: bootstrapSessionToken } =
+ yield* bootstrapBrowserSession();
assert.equal(bootstrapResponse.status, 200);
+ assert(bootstrapSessionToken, "Expected session token in Set-Cookie header");
const wsUrl = appendSessionTokenToUrl(
yield* getWsServerUrl("/ws", { authenticated: false }),
- bootstrapBody.sessionToken,
+ bootstrapSessionToken,
);
const response = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverGetConfig]({})),
diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts
--- a/apps/web/src/authBootstrap.test.ts
+++ b/apps/web/src/authBootstrap.test.ts
@@ -65,7 +65,6 @@
jsonResponse({
authenticated: true,
sessionMethod: "browser-session-cookie",
- sessionToken: "session-token",
expiresAt: "2026-04-05T00:00:00.000Z",
}),
);
@@ -207,7 +206,6 @@
jsonResponse({
authenticated: true,
sessionMethod: "browser-session-cookie",
- sessionToken: "session-token",
expiresAt: "2026-04-05T00:00:00.000Z",
}),
);
diff --git a/apps/web/src/authBootstrap.ts b/apps/web/src/authBootstrap.ts
--- a/apps/web/src/authBootstrap.ts
+++ b/apps/web/src/authBootstrap.ts
@@ -1,4 +1,4 @@
-import type { AuthBootstrapInput, AuthBootstrapResult, AuthSessionState } from "@t3tools/contracts";
+import type { AuthBootstrapInput, AuthSessionState } from "@t3tools/contracts";
import { resolveServerHttpUrl } from "./lib/utils";
export type ServerAuthGateState =
@@ -56,7 +56,7 @@
return (await response.json()) as AuthSessionState;
}
-async function exchangeBootstrapCredential(credential: string): Promise<AuthBootstrapResult> {
+async function exchangeBootstrapCredential(credential: string): Promise<void> {
const payload: AuthBootstrapInput = { credential };
const response = await fetch(resolveServerHttpUrl({ pathname: "/api/auth/bootstrap" }), {
body: JSON.stringify(payload),
@@ -71,8 +71,6 @@
const message = await response.text();
throw new Error(message || `Failed to bootstrap auth session (${response.status}).`);
}
-
- return (await response.json()) as AuthBootstrapResult;
}
async function bootstrapServerAuth(): Promise<ServerAuthGateState> {
diff --git a/apps/web/test/authHttpHandlers.ts b/apps/web/test/authHttpHandlers.ts
--- a/apps/web/test/authHttpHandlers.ts
+++ b/apps/web/test/authHttpHandlers.ts
@@ -2,7 +2,6 @@
import { HttpResponse, http } from "msw";
const TEST_SESSION_EXPIRES_AT = "2026-05-01T12:00:00.000Z";
-const TEST_SESSION_TOKEN = "browser-test-session-token";
export function createAuthenticatedSessionHandlers(getAuthDescriptor: () => ServerAuthDescriptor) {
return [
@@ -18,7 +17,6 @@
HttpResponse.json({
authenticated: true,
sessionMethod: "browser-session-cookie",
- sessionToken: TEST_SESSION_TOKEN,
expiresAt: TEST_SESSION_EXPIRES_AT,
}),
),You can send follow-ups to the cloud agent here.
| httpOnly: true, | ||
| path: "/", | ||
| sameSite: "lax", | ||
| }), |
There was a problem hiding this comment.
Session cookie missing secure flag for non-loopback environments
Medium Severity
The session cookie set by the bootstrap endpoint omits the secure flag entirely. While this is fine for local development over HTTP, the auth model explicitly supports remote-reachable environments where TLS is expected. Without secure, the cookie could be sent over plaintext HTTP on a remote/tunneled connection, exposing the session token to network eavesdropping. The flag could be set conditionally based on the auth policy or the request protocol.
Reviewed by Cursor Bugbot for commit dca54c7. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
There are 10 total unresolved issues (including 7 from previous reviews).
Autofix Details
Bugbot Autofix prepared fixes for all 3 issues found in the latest run.
- ✅ Fixed: Desktop bootstrap token expires before backend ready
- Increased DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES from 5 to 10 minutes to provide a safer margin for slow backend startups, window creation, and renderer bootstrap exchange.
- ✅ Fixed: Unused parameter in
buildReconnectTitleafter refactoring- Removed the dead
buildReconnectTitlefunction entirely and inlined the constant string "Disconnected from T3 Server" at the single call site.
- Removed the dead
- ✅ Fixed: Module-level shared mutable state across parallel test runs
- Replaced the bare cached token string with a generation-tagged object so that stale tokens from prior server builds are automatically invalidated when
buildAppUnderTestincrements the generation counter.
- Replaced the bare cached token string with a generation-tagged object so that stale tokens from prior server builds are automatically invalidated when
Or push these changes by commenting:
@cursor push d5344ec03b
Preview (d5344ec03b)
diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts
--- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts
+++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts
@@ -22,7 +22,7 @@
readonly grant: BootstrapGrant;
};
-const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
+const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(10);
export const makeBootstrapCredentialService = Effect.gen(function* () {
const config = yield* ServerConfig;
diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts
--- a/apps/server/src/server.test.ts
+++ b/apps/server/src/server.test.ts
@@ -108,7 +108,8 @@
repositoryIdentity: true,
},
};
-let cachedDefaultSessionToken: string | null = null;
+let serverBuildGeneration = 0;
+let cachedDefaultSessionToken: { token: string; generation: number } | null = null;
const makeDefaultOrchestrationReadModel = () => {
const now = new Date().toISOString();
@@ -293,7 +294,7 @@
};
}) =>
Effect.gen(function* () {
- cachedDefaultSessionToken = null;
+ serverBuildGeneration += 1;
const fileSystem = yield* FileSystem.FileSystem;
const tempBaseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-router-test-" });
const baseDir = options?.config?.baseDir ?? tempBaseDir;
@@ -521,8 +522,13 @@
const getAuthenticatedSessionToken = (credential = defaultDesktopBootstrapToken) =>
Effect.gen(function* () {
- if (credential === defaultDesktopBootstrapToken && cachedDefaultSessionToken) {
- return cachedDefaultSessionToken;
+ const currentGeneration = serverBuildGeneration;
+ if (
+ credential === defaultDesktopBootstrapToken &&
+ cachedDefaultSessionToken &&
+ cachedDefaultSessionToken.generation === currentGeneration
+ ) {
+ return cachedDefaultSessionToken.token;
}
const { response, body } = yield* bootstrapBrowserSession(credential);
@@ -533,7 +539,7 @@
}
if (credential === defaultDesktopBootstrapToken) {
- cachedDefaultSessionToken = body.sessionToken;
+ cachedDefaultSessionToken = { token: body.sessionToken, generation: currentGeneration };
}
return body.sessionToken;
diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx
--- a/apps/web/src/components/WebSocketConnectionSurface.tsx
+++ b/apps/web/src/components/WebSocketConnectionSurface.tsx
@@ -54,10 +54,6 @@
return "Retries exhausted trying to reconnect";
}
-function buildReconnectTitle(_status: WsConnectionStatus): string {
- return "Disconnected from T3 Server";
-}
-
function describeRecoveredToast(
previousDisconnectedAt: string | null,
connectedAt: string | null,
@@ -270,7 +266,7 @@
? `Reconnecting... ${formatReconnectAttemptLabel(status)}`
: `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`,
timeout: 0,
- title: buildReconnectTitle(status),
+ title: "Disconnected from T3 Server",
type: "loading" as const,
data: {
hideCopyButton: true,You can send follow-ups to the cloud agent here.
3b06cc9 to
3759e05
Compare
314c455 to
54f905c
Compare
3759e05 to
4caff6f
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Desktop bootstrap token consumed but still advertised via IPC
- The IPC handler now clears backendBootstrapToken after the first read, so subsequent calls return undefined instead of the stale, already-consumed token.
Or push these changes by commenting:
@cursor push eebbb23e4b
Preview (eebbb23e4b)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -1239,10 +1239,14 @@
ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
+ const token = backendBootstrapToken || undefined;
+ if (token) {
+ backendBootstrapToken = "";
+ }
event.returnValue = {
label: "Local environment",
wsUrl: backendWsUrl || null,
- bootstrapToken: backendBootstrapToken || undefined,
+ bootstrapToken: token,
} as const;
});You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 4 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Network-accessible preference permanently lost on transient network failure
- Added a
degradedflag to skip persisting the settings when the mode was downgraded from network-accessible to local-only due to no available LAN address, preserving the user's original preference.
- Added a
- ✅ Fixed: Loopback hostname check inconsistent with auth policy
- Added
normalizedHostname.startsWith("127.")toisLoopbackHostnameinhttp.tsto align with the broader127.x.x.xrange already recognized byisLoopbackHostinServerAuthPolicy.ts.
- Added
Or push these changes by commenting:
@cursor push 3b7b4cb840
Preview (3b7b4cb840)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -220,11 +220,13 @@
...(advertisedHostOverride ? { advertisedHostOverride } : {}),
});
+ let degraded = false;
if (mode === "network-accessible" && exposure.mode !== "network-accessible") {
if (options?.rejectIfUnavailable) {
throw new Error("No reachable network address is available for this desktop right now.");
}
mode = "local-only";
+ degraded = true;
}
desktopServerExposureMode = exposure.mode;
@@ -241,7 +243,7 @@
backendEndpointUrl = exposure.endpointUrl;
backendAdvertisedHost = exposure.advertisedHost;
- if (options?.persist || exposure.mode !== mode) {
+ if (!degraded && (options?.persist || exposure.mode !== mode)) {
writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
}
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -34,7 +34,7 @@
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, "$1");
- return LOOPBACK_HOSTNAMES.has(normalizedHostname);
+ return LOOPBACK_HOSTNAMES.has(normalizedHostname) || normalizedHostname.startsWith("127.");
}
export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {You can send follow-ups to the cloud agent here.
9f966e4 to
dc69564
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Unused
authSessionRouteLayerexport is dead code- Removed the unused
authSessionRouteLayerexport which was dead code, since onlyauthSessionCorsRouteLayer(with CORS support) is actually imported and used inserver.ts.
- Removed the unused
Or push these changes by commenting:
@cursor push b263b7fc96
Preview (b263b7fc96)
diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -28,17 +28,6 @@
);
});
-export const authSessionRouteLayer = HttpRouter.add(
- "GET",
- "/api/auth/session",
- Effect.gen(function* () {
- const request = yield* HttpServerRequest.HttpServerRequest;
- const serverAuth = yield* ServerAuth;
- const session = yield* serverAuth.getSessionState(request);
- return HttpServerResponse.jsonUnsafe(session, { status: 200 });
- }),
-);
-
const REMOTE_AUTH_ALLOW_METHODS = "GET, POST, OPTIONS";
const REMOTE_AUTH_ALLOW_HEADERS = "authorization, content-type";You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Fallback log message never emitted due to mutated state
- Captured the original serverExposureMode into a local variable before calling applyDesktopServerExposureMode, so the fallback check compares against the pre-mutation value.
Or push these changes by commenting:
@cursor push 552d1cfcbe
Preview (552d1cfcbe)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -1667,18 +1667,16 @@
`bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`,
);
}
- const serverExposureState = await applyDesktopServerExposureMode(
- desktopSettings.serverExposureMode,
- {
- persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
- },
- );
+ const requestedExposureMode = desktopSettings.serverExposureMode;
+ const serverExposureState = await applyDesktopServerExposureMode(requestedExposureMode, {
+ persist: requestedExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
+ });
writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`);
if (serverExposureState.endpointUrl) {
writeDesktopLogHeader(
`bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`,
);
- } else if (desktopSettings.serverExposureMode === "network-accessible") {
+ } else if (requestedExposureMode === "network-accessible") {
writeDesktopLogHeader(
"bootstrap fell back to local-only because no advertised network host was available",
);You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Dev redirect skips non-loopback LAN requests to dev server
- Removed the isLoopbackHostname guard from the dev redirect condition so all requests are redirected to the Vite dev server when devUrl is configured, regardless of the incoming hostname.
Or push these changes by commenting:
@cursor push b05c9dd39a
Preview (b05c9dd39a)
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -219,7 +219,7 @@
}
const config = yield* ServerConfig;
- if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
+ if (config.devUrl) {
return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
status: 302,
});You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Loopback local URLs unreachable when binding specific host
- Changed the specific-host branch in resolveDesktopServerExposure to compute localHttpUrl and localWsUrl using selectedHost instead of the hardcoded 127.0.0.1 loopback address, so the desktop app connects to the address the server actually binds to.
Or push these changes by commenting:
@cursor push 94810f1dd5
Preview (94810f1dd5)
diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts
--- a/apps/desktop/src/serverExposure.ts
+++ b/apps/desktop/src/serverExposure.ts
@@ -130,8 +130,8 @@
return {
mode: input.mode,
bindHost: selectedHost,
- localHttpUrl,
- localWsUrl,
+ localHttpUrl: `http://${selectedHost}:${input.port}`,
+ localWsUrl: `ws://${selectedHost}:${input.port}`,
endpointUrl: `http://${selectedHost}:${input.port}`,
advertisedHost: selectedHost,
availableHosts,You can send follow-ups to the cloud agent here.
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Julius Marminge <julius@macmini.local>
- Persist and migrate the chosen exposure host - Let desktop users pick a bind host before enabling network access - Update IPC, server exposure resolution, and tests
- Assert the settings panel calls `setServerExposure` with mode and host - Reflect the new network-accessible pairing flow in the browser test
- Centralize pairing link and session issuance/revocation - Simplify desktop server exposure handling and IPC - Update web settings and tests for the new auth flow
- Suppress minimum-log-level noise for pairing and session commands when emitting JSON - Update CLI tests to capture stdout via TestConsole
- Keep composer assertions valid after thread canonicalization - Wait for promoted drafts to settle before creating a fresh draft
- Replace bootstrap one-time credentials with 8-char pairing tokens - Add coverage for the pairing token format
- Issue and consume pairing links via `#token=...` - Keep query-token handling as a backward-compatible fallback - Update CLI, server, and web tests for the new format
- Extend bootstrap one-time tokens from 8 to 12 chars - Update the bootstrap credential test to match the new format
- Persist and surface auth session lastConnectedAt - Replace qrcode.react with a vendored SVG QR renderer - Update settings UI to show richer connection status
Co-authored-by: codex <codex@users.noreply.github.com>
8cef991 to
aad7243
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Expired seeded bootstrap credential returns wrong error message
- Added a 'not_found' tag to ConsumeResult so expired seeded credentials return the specific 'Bootstrap credential expired.' error immediately instead of falling through to the database path which returns a generic 'Unknown bootstrap credential.' error.
Or push these changes by commenting:
@cursor push 756ae604aa
Preview (756ae604aa)
diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts
--- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts
+++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts
@@ -19,6 +19,7 @@
}
type ConsumeResult =
+ | { readonly _tag: "not_found" }
| {
readonly _tag: "error";
readonly error: BootstrapCredentialError;
@@ -175,13 +176,7 @@
(current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
const grant = current.get(credential);
if (!grant) {
- return [
- {
- _tag: "error",
- error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
- },
- current,
- ];
+ return [{ _tag: "not_found" }, current];
}
const next = new Map(current);
@@ -227,6 +222,9 @@
if (seededResult._tag === "success") {
return seededResult.grant;
}
+ if (seededResult._tag === "error") {
+ return yield* seededResult.error;
+ }
const consumed = yield* pairingLinks.consumeAvailable({
credential,You can send follow-ups to the cloud agent here.
- Add per-request timeout and retry for backend readiness checks - Fall back to local-only exposure when network access is unavailable - Keep the existing window on app activate and remove react-scan script
- Create secrets with exclusive open and retry after AlreadyExists - Treat expired bootstrap tokens as hard failures - Improve CLI and keybinding validation messages
- Persist the user’s requested server exposure mode even when a safer mode is applied temporarily - Route auth bootstrap cookies through the session credential service - Tighten secret-store read error handling for concurrent startup
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Unused
appliedModeparameter creates misleading API- Removed the unused
appliedModefield from the function signature, its call site inmain.ts, and the test.
- Removed the unused
- ✅ Fixed: Session cookie name duplicated across independent modules
- Extracted
SESSION_COOKIE_NAMEtoServices/SessionCredentialService.tsand imported it in bothServerAuthPolicy.tsand the layerSessionCredentialService.ts, eliminating the duplicated constant.
- Extracted
Or push these changes by commenting:
@cursor push d38c659f7a
Preview (d38c659f7a)
diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts
--- a/apps/desktop/src/desktopSettings.test.ts
+++ b/apps/desktop/src/desktopSettings.test.ts
@@ -50,7 +50,6 @@
},
{
requestedMode: "network-accessible",
- appliedMode: "local-only",
},
),
).toEqual({
diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts
--- a/apps/desktop/src/desktopSettings.ts
+++ b/apps/desktop/src/desktopSettings.ts
@@ -14,7 +14,6 @@
settings: DesktopSettings,
input: {
readonly requestedMode: DesktopServerExposureMode;
- readonly appliedMode: DesktopServerExposureMode;
},
): DesktopSettings {
const persistedMode = input.requestedMode;
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -237,7 +237,6 @@
desktopServerExposureMode = exposure.mode;
desktopSettings = setDesktopServerExposurePreference(desktopSettings, {
requestedMode,
- appliedMode: exposure.mode,
});
backendBindHost = exposure.bindHost;
backendHttpUrl = exposure.localHttpUrl;
diff --git a/apps/server/src/auth/Layers/ServerAuthPolicy.ts b/apps/server/src/auth/Layers/ServerAuthPolicy.ts
--- a/apps/server/src/auth/Layers/ServerAuthPolicy.ts
+++ b/apps/server/src/auth/Layers/ServerAuthPolicy.ts
@@ -3,9 +3,8 @@
import { ServerConfig } from "../../config.ts";
import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
+import { SESSION_COOKIE_NAME } from "../Services/SessionCredentialService.ts";
-const SESSION_COOKIE_NAME = "t3_session";
-
const isWildcardHost = (host: string | undefined): boolean =>
host === "0.0.0.0" || host === "::" || host === "[::]";
diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts
--- a/apps/server/src/auth/Layers/SessionCredentialService.ts
+++ b/apps/server/src/auth/Layers/SessionCredentialService.ts
@@ -6,6 +6,7 @@
import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
import {
+ SESSION_COOKIE_NAME,
SessionCredentialError,
SessionCredentialService,
type IssuedSession,
@@ -21,7 +22,6 @@
} from "../tokenCodec.ts";
const SIGNING_SECRET_NAME = "server-signing-key";
-const SESSION_COOKIE_NAME = "t3_session";
const DEFAULT_SESSION_TTL = Duration.days(30);
const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts
--- a/apps/server/src/auth/Services/SessionCredentialService.ts
+++ b/apps/server/src/auth/Services/SessionCredentialService.ts
@@ -81,6 +81,8 @@
readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
}
+export const SESSION_COOKIE_NAME = "t3_session";
+
export class SessionCredentialService extends ServiceMap.Service<
SessionCredentialService,
SessionCredentialServiceShapeYou can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Electron browser detection is unreachable dead code
- Moved the Electron check before the Chrome check in inferBrowser so Electron's UA string (which contains 'Chrome/') is correctly identified as Electron.
Or push these changes by commenting:
@cursor push bb1611aabd
Preview (bb1611aabd)
diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts
--- a/apps/server/src/auth/utils.ts
+++ b/apps/server/src/auth/utils.ts
@@ -67,10 +67,10 @@
const normalized = userAgent;
if (/Edg\//.test(normalized)) return "Edge";
if (/OPR\//.test(normalized)) return "Opera";
+ if (/Electron\//.test(normalized)) return "Electron";
if (/Firefox\//.test(normalized)) return "Firefox";
if (/Chrome\//.test(normalized) || /CriOS\//.test(normalized)) return "Chrome";
if (/Safari\//.test(normalized) && !/Chrome\//.test(normalized)) return "Safari";
- if (/Electron\//.test(normalized)) return "Electron";
return undefined;
}You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 16a5be7. Configure here.
| if (/Firefox\//.test(normalized)) return "Firefox"; | ||
| if (/Chrome\//.test(normalized) || /CriOS\//.test(normalized)) return "Chrome"; | ||
| if (/Safari\//.test(normalized) && !/Chrome\//.test(normalized)) return "Safari"; | ||
| if (/Electron\//.test(normalized)) return "Electron"; |
There was a problem hiding this comment.
Electron browser detection is unreachable dead code
Low Severity
inferBrowser checks for Chrome/ on line 71 before checking for Electron/ on line 73, but Electron's user-agent string always includes Chrome/. This means the Electron branch is unreachable dead code, and desktop Electron sessions will always be labeled as "Chrome" in AuthClientMetadata.
Reviewed by Cursor Bugbot for commit 16a5be7. Configure here.
- Stop retrying subscriptions after application-level stream failures - Keep retry loops for transport disconnects only - Add tests for both failure paths



Summary
/pairroute for browser pairingbeforeLoad, add a first-paint loading shell, and document the auth architectureTesting
Note
High Risk
Introduces a new server-wide authentication system (bootstrap credentials, signed sessions, WS tokens) and removes the legacy static token path, impacting all HTTP/WS access and potentially breaking existing clients/configs that relied on
authToken/T3CODE_AUTH_TOKEN.Overview
Adds a new server-wide auth subsystem built around one-time bootstrap credentials, signed session tokens (cookie + bearer), and short-lived WebSocket upgrade tokens, exposed via new
/api/auth/*HTTP routes for session state, bootstrap exchange, pairing token management, and client/session revocation.Updates server routing to enforce authentication on privileged HTTP surfaces (e.g. attachments/observability proxy) using
ServerAuth, and introduces policy-driven defaults (desktop-managed-local,loopback-browser,remote-reachable) based on mode/host binding.Extends the desktop app to support configurable server exposure (
local-onlyvsnetwork-accessible) persisted to disk with IPC APIs, switches startup to a desktop bootstrap token (replacing the oldauthTokenflow), and adds backend readiness polling for dev/prod startup sequencing.Also adds architecture/planning docs for remote environments and the long-term auth model, plus minor tooling config updates (formatter ignore patterns).
Reviewed by Cursor Bugbot for commit 853d447. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Implement server auth bootstrap and pairing flow with session management
/pairroute in the web app for credential exchangeauth pairing create/list/revoke,auth session issue/list) manage credentials from the command line; desktop mode passes adesktopBootstrapTokeninstead of the oldauthToken/api/auth/session,/api/auth/bootstrap,/api/auth/ws-token, etc.) are added and all existing HTTP/WS routes now require authentication viaServerAuthChatViewnow group projects across environments, show remote environment badges/labels, and support switching environments from the toolbarauthTokenis removed from config and CLI; callers must migrate todesktopBootstrapToken. All HTTP and WebSocket endpoints now return 401/403 when unauthenticated, which is a breaking behavioral change for unauthenticated clients.Macroscope summarized 853d447.